Skip to content

Plugin loader: version gate reads running code, SystemExit isolated, uv quarantine from any cwd, impostor dirs refused, range pins (#72052 #104404 #101962 #112096 #108371 #71650 #86992 #98407) - #118841

Merged
teknium1 merged 14 commits into
mainfrom
fix/plugin-loader-robustness
Sep 22, 2026
Merged

teknium1 merged 14 commits into
mainfrom
fix/plugin-loader-robustness

Conversation

@teknium1

@teknium1 teknium1 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Plugin loading now gates on the version that is actually running, survives a plugin that calls sys.exit(), installs plugin deps under the uv exclude-newer quarantine from any cwd, refuses impostor directories that claim a bundled plugin's key, and stops downgrading newer hindsight/mem0 SDKs — plus seven salvaged contributor fixes for loader edge cases.

Fix pack from the 2026-09-21 plugin-loader audit (lane L1) and bug-sweep clusters 4/6/8. Every symptom was live-reproduced on origin/main in a temp HOME/HERMES_HOME before the fix and re-run after.

Changes

Loader (own fixes)

  • requires_hermes compared against stale editable-install dist metadata (importlib.metadata said 0.21.0 while the checkout ran 0.21.4), so a plugin requiring >=0.21.4 was skipped on 0.21.4. running_hermes_version() now reads hermes_cli.__version__; dist metadata is the fallback.
  • PEP 440 suffixes glued to a segment (99.0.0rc1) parsed as None: an rc target silently gated nothing and an rc running version disabled every gate. _version_tuple drops the suffix.
  • A plugin calling sys.exit() at import or in register() propagated SystemExit out of discovery: the whole registry emptied, _discovered reset and hermes chat -Q exited with no output. _load_plugin_scoped / _load_portable_plugin / deferred-platform tool pre-registration now isolate SystemExit (named as SystemExit(code) raised during import/register()); KeyboardInterrupt still propagates.
  • uv reads [tool.uv] exclude-newer from the cwd project only, so install_specs / lazy installs launched from $HOME, a gateway service or the Desktop backend never got the 14-day quarantine (verified with uv pip install --show-settings: exclude_newer: None from $HOME, 14d from the checkout). The uv tier now runs with cwd=<checkout root> when pyproject.toml exists, which also brings the [tool.uv.exclude-newer-package] exceptions along. Settles the "does quarantine cover plugin deps?" question: it does now, everywhere.
  • A flat user/project manifest whose name: is a bundled key but whose directory is named differently (~/.hermes/plugins/impostor_dir with name: disk-cleanup) displaced the bundled plugin silently, so hermes plugins enable disk-cleanup activated unrelated code. resolve_manifest_winners (plugins_discovery) keeps the bundled manifest and warns; a same-named user copy still overrides (documented later-wins) and is logged at INFO.
  • LAZY_DEPS exact pins (hindsight-client==0.6.1, mem0ai==2.0.10) made _is_satisfied() reject every newer compatible release, so hermes update (and hindsight's own >=_MIN_CLIENT_VERSION auto-upgrade) kept downgrading a working 0.9.x client and broke embedded daemons whose DB a newer client had migrated. Policy: plugin-owned SDK entries mirror the range their plugin.yaml declares (>=0.6.1,<1, >=2.0.10,<3); pyproject extras stay the floor install (tests/test_project_metadata.py only cross-checks packages exact-pinned on both sides). Slim redo of fix(mem0): preserve compatible installed releases #99557 (@nateEc), fix(deps): bump mem0ai exact pins from 2.0.10 to 2.0.19 #98416, fix(memory): align Hindsight client pin at 0.9.2 #98527 (@ttomiczek), fix(plugins): repair hindsight local runtime activation (#39424) #39754.
  • A list-typed plugin.yaml is refused with top level must be a mapping instead of an AttributeError swallowed as "Failed to parse" (plugins install crashes when plugin.yaml is valid YAML but not a mapping #14066, discovery side; the plugins_cmd _read_manifest path is a separate file and is left for its lane).
  • hooks: (the spelling the bundled manifests carried) still populates provides_hooks, so external copies keep declaring the same thing.

Salvaged contributor fixes (cherry-picked, authorship preserved, re-sited onto the post-#102117 siblings)

Validation

Check Before (origin/main ab1f70f) After
running_hermes_version() on this editable install 0.21.0 (dist) — plugin requires_hermes: ">=0.21.4" skipped 0.21.4 — plugin loads
version_satisfies(">=99.0.0rc1", "0.21.4") True (clause dropped) False
hermes chat -Q -q "say hi" with a sys.exit(0) plugin enabled exit with no output, registry empty reaches the provider (control 401 on the fake key), plugin recorded with SystemExit(0) raised during import/register()
uv pip install --show-settings cwd=$HOME exclude_newer: None uv tier invoked with cwd=<checkout> → 14d
impostor_dir/plugin.yaml name: disk-cleanup winner src=user path=…/impostor_dir winner src=bundled, WARNING names the dir and the rename
feature_missing("memory.hindsight") with 0.9.2 installed ("hindsight-client==0.6.1",) → reinstall/downgrade ()
New invariant tests (8) on base / fixed 8 red 8 green
scripts/run_tests.sh tests/hermes_cli/ tests/plugins/ tests/tools/test_lazy_deps.py tests/tools/test_plugin_skills.py tests/test_project_metadata.py tests/test_packaging_metadata.py — 15401 passed, 0 related failures (8 host-environmental reds also red on pristine main: port-held dashboard auth gate, timing tests); langfuse manifest test updated for the provides_hooks: rename

Live repro: before — HOME=<fake> HERMES_HOME=<fake>/.hermes python -m hermes_cli.main chat -Q -q "say hi" with ~/.hermes/plugins/b_exit/__init__.py = import sys; sys.exit(0) produced zero output and an empty plugin registry; after — normal provider round-trip, b_exit listed with its error, c_after loaded. Probe script: ~/.hermes/cache/scratch/fixpack-0921/loader/probe_loader.py (A/B output in the PR thread on request).

Root cause in one sentence: the loader trusted three things that drift from the running process — install-time dist metadata, except Exception for import failures, and uv's cwd-scoped project policy.

Fixes #72052, fixes #104404, fixes #101962, fixes #86996, fixes #112096, fixes #108371, fixes #71650, fixes #91757, fixes #95529, fixes #86992, fixes #39424, fixes #98407, fixes #99317. Part of #14066 (discovery half).
Supersedes (with credit) #72094, #104418, #101977, #87014, #112097, #108386, #97765, #116425, #99557, #98416, #98527, #39754, #91761, #89345, #89351.

Deferred (outside this lane's files or needs a design call): #53704 (main.py top-level KeyboardInterrupt), #48822 (plugins/memory/__init__.py flat scan), #108139 (no cheap per-plugin register() deadline; #108144 fixes lock re-entry only), #86231/#59547/#107098 (config.py/toolset_validation.py), #89078 (secret-source registry warning at dotenv time), #110084 (env_loader → config import order), #98438 (providers/__init__.py ep.load before kind gate), #95855 (third-party dep graph), #69148 (user backend copy vs bundled auto-load — policy).

Infographic

plugin-loader-hardening

@teknium1
teknium1 requested a review from a team September 22, 2026 06:12
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

ran on 0d49490 — test(plugins): langfuse manifest test reads provides_hooks (

debug info

CI timings

CI timings · View report · View job

Wall time 4m57s vs 5m44s (-13.7%). 3 job(s) slower, 8 faster, 2 unchanged.

  • OS-specific tests / Windows-only tests: -41.0s
  • OS-specific tests / macOS-only tests: +18.0s
  • Check contributors / check-attribution: -17.0s
  • Python tests / Run tests: -8.0s
  • Python lints / Windows footguns (blocking): -7.0s

@teknium1 teknium1 added the ci-reviewed applied to manually approve dangerous changes label Sep 22, 2026
teknium1 and others added 14 commits September 21, 2026 23:20
…it, quarantine deps everywhere

- requires_hermes compared against stale editable-install dist metadata (0.21.0) while the
  checkout ran 0.21.4, skipping plugins that required the release in use; hermes_cli.__version__
  is now the source of truth, dist metadata only a fallback.
- PEP 440 pre/post suffixes glued to a segment (99.0.0rc1) made a clause permissive and an rc
  running version disabled every gate; the segment parser drops the suffix.
- A plugin calling sys.exit() at import or in register() propagated SystemExit out of
  discovery: the whole registry emptied and `hermes chat` exited 3 with no output. Load
  isolation now covers SystemExit (KeyboardInterrupt still propagates).
- uv reads [tool.uv] exclude-newer from the cwd project only, so lazy/plugin dep installs
  launched from $HOME, a gateway service or the Desktop backend were never quarantined; the uv
  tier now runs from the checkout root when one exists.
- A flat user/project manifest naming a bundled key from a differently named directory no longer
  displaces the bundled plugin (warn + skip); a same-named override is logged at INFO.
Plugin register() helpers commonly pass the SKILL.md location as a
filesystem string (PluginManifest.path itself is stored as str), but
register_skill() called path.exists() directly, so a valid string path
aborted the whole plugin load with "'str' object has no attribute
'exists'" instead of registering. Coerce to Path up front so the
registry entry and find_plugin_skill() keep their Path contract, and a
missing location still fails with FileNotFoundError.

Fixes #104404
EntryPoint.load() resolves the module:function form to the referenced
attribute (typically the register callable itself), not its module. The
loader then looked for a .register attribute on that function object,
found none, and warned "Plugin '<name>' has no register() function" on
every discovery pass — so pip plugins with module:function entry points
never registered at all.

Detect a non-module callable from ep.load() and use it directly as the
register function, resolving LoadedPlugin.module from sys.modules via
the callable's __module__ for attribution.

The existing entry-point test masked this because its mocked ep.load()
returned the module even though its declared value was module:function;
the new regression test mirrors real importlib behavior.

Fixes #72052

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Walking __pycache__ and other dunder children let an unreadable
directory raise out of plugin discovery and fail unrelated tool
calls. Skip those names and treat iterdir/is_dir OSError as an
empty scan for that node.

Fixes #86996
Name-based dunder skipping does not need an unreadable __pycache__.
Drop the chmod(0) setup so the test is portable, and log skipped
dunder paths at debug.
Multi-harness plugin repos (e.g. obra/superpowers) ship one plugin.json
per OTHER agent harness inside .claude-plugin/, .codex-plugin/,
.cursor-plugin/, .devin-plugin/ and .kimi-plugin/. Those manifests can
never satisfy the Agent Plugins v1 schema, so every discovery pass
rejected each one and logged a warning — ~1,500 warnings/day on the
reporter's install (#101962).

Skip these well-known per-harness convention directories during
directory scanning. A plugin's real Hermes manifest
(.hermes-plugin/plugin.yaml or a top-level plugin.yaml/plugin.json) is
unaffected, and genuinely broken portable manifests still warn.

Fixes #101962
…mes list-typed files and reads hooks:

- hindsight-client==0.6.1 / mem0ai==2.0.10 exact pins made _is_satisfied() reject every newer
  compatible release, so hermes update kept downgrading a working client and broke embedded
  daemons whose DB a newer client had migrated (#86992, #39424, #98407, #99317). The lazy entries
  now mirror the plugin manifests (>=0.6.1,<1 and >=2.0.10,<3); pyproject extras stay the floor
  install. Slim redo of #99557 (nateEc) / #98416 / #98527 (ttomiczek) / #39754.
- A list-typed plugin.yaml is refused with "top level must be a mapping" instead of an
  AttributeError swallowed as "Failed to parse" (#14066, discovery side).
- ``hooks:`` (the spelling bundled manifests carried) still populates provides_hooks (#108371).
Plugins register their toolsets during background discovery, but the CLI
validates the configured toolset list while the instance is being
constructed -- i.e. before that thread has landed. Judging by the live
registry alone therefore reports every configured plugin toolset as a
typo on every launch.

The warning is not merely cosmetic: it is written to the console, so
one-shot and quiet runs (-q / -Q, --format stream-json) hand it to
whatever parses their output; an integration reading the response can
receive the warning line instead of the answer.

Skip names the plugin registry knows about, mirroring how MCP server
names are already skipped here. Names persisted by the previous launch's
discovery sweep are served by get_plugin_toolset_keys_nowait, so this
stays non-blocking on startup. A genuinely unknown name still warns.

Refs #71650 (#95529 is the duplicate report of the same bug)

Co-authored-by: adamkrawczyk <adam-krawczyk@outlook.com>
@teknium1
teknium1 force-pushed the fix/plugin-loader-robustness branch from fa2ef1a to 0d49490 Compare September 22, 2026 06:20
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins python:uv Pull requests that update python:uv code dependencies Pull requests that update a dependency file sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Sep 22, 2026
@teknium1
teknium1 merged commit 74f726c into main Sep 22, 2026
38 checks passed
@teknium1
teknium1 deleted the fix/plugin-loader-robustness branch September 22, 2026 06:26
This was referenced Sep 22, 2026
teknium1 added a commit that referenced this pull request Sep 23, 2026
…licy

Hermes's 14-day `[tool.uv] exclude-newer` quarantine applies to Hermes's own
dependencies only (uv lock/sync, `hermes update`, LAZY_DEPS extras via
`ensure()`). A plugin's declared `python_dependencies` install under the
PLUGIN's policy: `install_specs(policy="plugin")` runs uv with `--no-config`
from any cwd, still inside the core constraints file.

Reverses item 3 of #118841, which ran the uv tier with cwd=<checkout> for
every install so the quarantine reached plugin deps from any cwd. That made
catalog re-pins floored on a <14-day release uninstallable (#120076:
"only hindsight-client<=0.9.2 is available"; #114530 held on the same gate).

Maintainer ruling (Teknium): "plugins dont have to abide by our 14 day rule
btw. They can have their own security policy on that. Only hermes'
dependencies themselves have to. We should recommend that they do this for
their plugins and we should give guidance to plugin devs that they should
though."

- tools/lazy_deps.py: INSTALL_POLICIES ("core" | "plugin"); `_uv_policy_args`
  replaces `_uv_policy_cwd`; `_venv_pip_install(policy=)` defaults to core
  (ensure/LAZY_DEPS), `install_specs(policy=)` defaults to plugin.
- hermes_cli/plugin_python_deps.py: `resolve()` passes policy="plugin".
- Docs: developer guide "Dependency security policy" section, catalog README
  admission rule 9, AGENTS.md pinning policy — plugin authors are responsible
  for their deps and strongly recommended to pin upper bounds, floor on the
  oldest API-compatible version and run their own release quarantine
  (`uv --exclude-newer` in their CI); operators can set UV_EXCLUDE_NEWER.
- Tests: the #118841 cwd test is replaced by two invariants — a plugin install
  carries `--no-config` and no checkout cwd (red on base), a core lazy install
  keeps the checkout cwd and no `--no-config`.
teknium1 added a commit that referenced this pull request Sep 23, 2026
…licy

Hermes's 14-day `[tool.uv] exclude-newer` quarantine applies to Hermes's own
dependencies only (uv lock/sync, `hermes update`, LAZY_DEPS extras via
`ensure()`). A plugin's declared `python_dependencies` install under the
PLUGIN's policy: `install_specs(policy="plugin")` runs uv with `--no-config`
from any cwd, still inside the core constraints file.

Reverses item 3 of #118841, which ran the uv tier with cwd=<checkout> for
every install so the quarantine reached plugin deps from any cwd. That made
catalog re-pins floored on a <14-day release uninstallable (#120076:
"only hindsight-client<=0.9.2 is available"; #114530 held on the same gate).

Maintainer ruling (Teknium): "plugins dont have to abide by our 14 day rule
btw. They can have their own security policy on that. Only hermes'
dependencies themselves have to. We should recommend that they do this for
their plugins and we should give guidance to plugin devs that they should
though."

- tools/lazy_deps.py: INSTALL_POLICIES ("core" | "plugin"); `_uv_policy_args`
  replaces `_uv_policy_cwd`; `_venv_pip_install(policy=)` defaults to core
  (ensure/LAZY_DEPS), `install_specs(policy=)` defaults to plugin.
- hermes_cli/plugin_python_deps.py: `resolve()` passes policy="plugin".
- Docs: developer guide "Dependency security policy" section, catalog README
  admission rule 9, AGENTS.md pinning policy — plugin authors are responsible
  for their deps and strongly recommended to pin upper bounds, floor on the
  oldest API-compatible version and run their own release quarantine
  (`uv --exclude-newer` in their CI); operators can set UV_EXCLUDE_NEWER.
- Tests: the #118841 cwd test is replaced by two invariants — a plugin install
  carries `--no-config` and no checkout cwd (red on base), a core lazy install
  keeps the checkout cwd and no `--no-config`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-reviewed applied to manually approve dangerous changes comp/cli CLI entry point, hermes_cli/, setup wizard comp/plugins Plugin system and bundled plugins dependencies Pull requests that update a dependency file P2 Medium — degraded but workaround exists python:uv Pull requests that update python:uv code sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

9 participants